AWS CDKでECS Fargate Bastionを一撃で作ってみた
EC2インスタンスの踏み台を用意したくない
こんにちは、のんピ(@non____97)です。
皆さんはEC2インスタンスの踏み台を用意したくないと思ったことはありますか? 私はあります。
VPC上のRDS DBインスタンスやRedisクラスター、OpenSearch Service ドメインなどのリソースに接続したい場合、Site-to-Site VPNやClient VPN、Direct Connectがなければ踏み台(Bastion)が必要になります。
踏み台へのアクセス方法は以下のようなものがあります。
- 直接SSH
- SSMセッションマネージャー
- EC2 Instance Connect
そして、踏み台となるリソースとして採用される多くがEC2インスタンスだと考えます。EC2インスタンスの場合、OS周りの面倒をみる必要があります。OS内のパッケージのアップデートが面倒であれば「踏み台が欲しいタイミングにEC2インスタンスを一瞬だけ作って、不要になったら削除する」という運用も考えられますが、起動に時間がかかりそうです。また、ワクワクしません。
そんな時に活躍するのがECS Fargateを使った踏み台です。FargateなのでOSの面倒をみる必要がなくなります。コンテナなので起動もEC2インスタンスと比較して早いでしょう。そんなECS FargateのBastionは偉大な先人達が既に紹介しています。
私もAWS CDKを使って一撃でECS Fargate Bastionを作ってみたくなったのでチャレンジしてみました。ロマンを追い求めています。
使用するコードの構成
使用したコードは以下リポジトリに保存しています。
ディレクトリツリーは以下のとおりです。
> tree . ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── ecs-fargate-bastion.ts ├── cdk.context.json ├── cdk.json ├── jest.config.js ├── lib │ ├── construct │ │ ├── ecs-fargate-construct.ts │ │ └── vpc-endpoint-construct.ts │ ├── ecs-fargate-bastion-stack.ts │ └── parameter │ └── index.ts ├── package-lock.json ├── package.json ├── test │ └── ecs-fargate-bastion.test.ts └── tsconfig.json 6 directories, 15 files
「デプロイ先のVPCにはNAT Gatewayはない。ECSで使用するVPCエンドポイントもない」という環境もあるでしょう。ということで必要に応じてVPCエンドポイントも作成するような構成にしています。具体的なStackのコードは以下のとおりです。
import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import { VpcEndpointParams, EcsFargateParams } from "./parameter"; import { VpcEndpointConstruct } from "./construct/vpc-endpoint-construct"; import { EcsFargateConstruct } from "./construct/ecs-fargate-construct"; export interface EcsFargateBastionStackProps extends cdk.StackProps { vpcId: string; vpcEndpointParams?: VpcEndpointParams; ecsFargateParams: EcsFargateParams; } export class EcsFargateBastionStack extends cdk.Stack { constructor( scope: Construct, id: string, props: EcsFargateBastionStackProps ) { super(scope, id, props); // VPC Endpoints const vpcEndpointConstruct = props.vpcEndpointParams ? new VpcEndpointConstruct(this, "VpcEndpoint", { vpcId: props.vpcId, vpcEndpointParams: props.vpcEndpointParams, }) : undefined; // ECS Fargate const ecsFargateConstruct = new EcsFargateConstruct(this, "EcsFargate", { vpcId: props.vpcId, ecsFargateParams: props.ecsFargateParams, }); if (vpcEndpointConstruct) { ecsFargateConstruct.node.addDependency(vpcEndpointConstruct); } } }
「ECSで使用するVPCエンドポイントはあるけど、SSMセッションマネージャーで使用するVPCエンドポイントはない」といった場合もあると思います。後述する./lib/parameter/index.ts
で指定したフラグに応じて、以下のVPCエンドポイントを指定されたサブネット上に作成するようにしています。
shouldCreateEcrVpcEndpoint
が truecom.amazonaws.region.ecr.dkr
com.amazonaws.region.ecr.api
shouldCreateSsmVpcEndpoint
が truecom.amazonaws.region.ssm
com.amazonaws.region.ssmmessages
shouldCreateLogsVpcEndpoint
が truecom.amazonaws.region.ssm
shouldCreateS3VpcEndpoint
が truecom.amazonaws.region.s3
(Gateway型)
ECSで使用するVPCエンドポイントは以下記事にまとまっています。
具体的なVPCエンドポイントのConstructのコードは以下のとおりです。
import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import { VpcEndpointParams } from "../parameter"; export interface VpcEndpointConstructProps { vpcId: string; vpcEndpointParams: VpcEndpointParams; } export class VpcEndpointConstruct extends Construct { public readonly ecrRepository: cdk.aws_ecr.IRepository; constructor(scope: Construct, id: string, props: VpcEndpointConstructProps) { super(scope, id); // VPC const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", { vpcId: props.vpcId, }); if (props.vpcEndpointParams.shouldCreateEcrVpcEndpoint) { // ECR vpc.addInterfaceEndpoint("EcrEndpoint", { service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR, subnets: vpc.selectSubnets( props.vpcEndpointParams.vpcEndpointSubnetSelection ), }); // ECR DOCKER vpc.addInterfaceEndpoint("EcrDockerEndpoint", { service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, subnets: vpc.selectSubnets( props.vpcEndpointParams.vpcEndpointSubnetSelection ), }); } if (props.vpcEndpointParams.shouldCreateSsmVpcEndpoint) { // SSM vpc.addInterfaceEndpoint("SsmEndpoint", { service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM, subnets: vpc.selectSubnets( props.vpcEndpointParams.vpcEndpointSubnetSelection ), }); // SSM MESSAGES vpc.addInterfaceEndpoint("SsmMessagesEndpoint", { service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES, subnets: vpc.selectSubnets( props.vpcEndpointParams.vpcEndpointSubnetSelection ), }); } if (props.vpcEndpointParams.shouldCreateLogsVpcEndpoint) { // LOGS vpc.addInterfaceEndpoint("LogsEndpoint", { service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, subnets: vpc.selectSubnets( props.vpcEndpointParams.vpcEndpointSubnetSelection ), }); } if (props.vpcEndpointParams.shouldCreateS3VpcEndpoint) { // Gateway S3 vpc.addGatewayEndpoint(`S3GatewayEndpoint`, { service: cdk.aws_ec2.GatewayVpcEndpointAwsService.S3, }); } } }
ECS FargateのConstructは以下のとおりです。./lib/parameter/index.ts
で指定されたコンテナイメージを使って起動するというものです。「Pull through cacheを使いたいな」という方向けにも対応しています。
import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import { EcsFargateParams } from "../parameter"; export interface EcsFargateConstructProps { vpcId: string; ecsFargateParams: EcsFargateParams; } export class EcsFargateConstruct extends Construct { constructor(scope: Construct, id: string, props: EcsFargateConstructProps) { super(scope, id); // Pull through cache rules const pullThroughCacheRule = props.ecsFargateParams.ecrRepositoryPrefix ? new cdk.aws_ecr.CfnPullThroughCacheRule(this, "PullThroughCacheRule", { ecrRepositoryPrefix: props.ecsFargateParams.ecrRepositoryPrefix, upstreamRegistryUrl: "public.ecr.aws", }) : undefined; // VPC const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", { vpcId: props.vpcId, }); // Log Group const logGroup = new cdk.aws_logs.LogGroup(this, "LogGroup", { logGroupName: `/ecs/${props.ecsFargateParams.clusterName}/${props.ecsFargateParams.repositoryName}/ecs-exec`, removalPolicy: cdk.RemovalPolicy.DESTROY, retention: cdk.aws_logs.RetentionDays.TWO_WEEKS, }); // ECS Cluster const cluster = new cdk.aws_ecs.Cluster(this, "Cluster", { vpc, containerInsights: false, clusterName: props.ecsFargateParams.clusterName, executeCommandConfiguration: { logging: cdk.aws_ecs.ExecuteCommandLogging.OVERRIDE, logConfiguration: { cloudWatchLogGroup: logGroup, }, }, }); // Task definition const taskDefinition = new cdk.aws_ecs.FargateTaskDefinition( this, "TaskDefinition", { cpu: 256, memoryLimitMiB: 512, runtimePlatform: { cpuArchitecture: cdk.aws_ecs.CpuArchitecture.ARM64, operatingSystemFamily: cdk.aws_ecs.OperatingSystemFamily.LINUX, }, } ); // Container taskDefinition.addContainer("Container", { image: pullThroughCacheRule?.ecrRepositoryPrefix ? cdk.aws_ecs.ContainerImage.fromEcrRepository( cdk.aws_ecr.Repository.fromRepositoryName( this, pullThroughCacheRule.ecrRepositoryPrefix, props.ecsFargateParams.repositoryName ), props.ecsFargateParams.imagesTag ) : cdk.aws_ecs.ContainerImage.fromRegistry( `${props.ecsFargateParams.repositoryName}:${props.ecsFargateParams.imagesTag}` ), pseudoTerminal: true, linuxParameters: new cdk.aws_ecs.LinuxParameters( this, "LinuxParameters", { initProcessEnabled: true, } ), }); // Pull through cache Policy if (pullThroughCacheRule?.ecrRepositoryPrefix) { taskDefinition.obtainExecutionRole().attachInlinePolicy( new cdk.aws_iam.Policy(this, "PullThroughCachePolicy", { statements: [ new cdk.aws_iam.PolicyStatement({ actions: ["ecr:CreateRepository", "ecr:BatchImportUpstreamImage"], resources: [ `arn:aws:ecr:${cdk.Stack.of(this).region}:${ cdk.Stack.of(this).account }:repository/${props.ecsFargateParams.ecrRepositoryPrefix}/*`, ], }), ], }) ); } // Attache Security Group const securityGroups = props.ecsFargateParams.ecsServiceSecurityGroupIds ? props.ecsFargateParams.ecsServiceSecurityGroupIds.map( (securityGroupId) => { return cdk.aws_ec2.SecurityGroup.fromSecurityGroupId( this, `EcsServiceSecurityGroupId_${securityGroupId}`, securityGroupId ); } ) : undefined; // ECS Service const ecsService = new cdk.aws_ecs.FargateService(this, "Service", { cluster, enableExecuteCommand: true, taskDefinition, desiredCount: props.ecsFargateParams.desiredCount, minHealthyPercent: 100, maxHealthyPercent: 200, deploymentController: { type: cdk.aws_ecs.DeploymentControllerType.ECS, }, circuitBreaker: { rollback: true }, securityGroups, vpcSubnets: props.ecsFargateParams.ecsFargateSubnetSelection, }); // Allow dst Security Group from ECS Service Security Group props.ecsFargateParams.inboundFromEcsServiceAllowedSecurityGroupId?.forEach( (allowRule) => { const securityGroup = cdk.aws_ec2.SecurityGroup.fromSecurityGroupId( this, `InboundFromEcsServiceAllowedSecurityGroupId_${allowRule.securityGroupId}`, allowRule.securityGroupId ); allowRule.ports.forEach((port) => { ecsService.connections.securityGroups.forEach( (ecsServiceSecurityGroup) => { securityGroup.addIngressRule( ecsServiceSecurityGroup, port, `Inbound ${props.ecsFargateParams.clusterName} service` ); } ); }); } ); } }
どのコンテナイメージを使うのか、どのVPCのどのサブネットにデプロイするかは./lib/parameter/index.ts
で指定します。各プロパティの説明はコード内にコメントしています。
import * as cdk from "aws-cdk-lib"; export interface VpcEndpointParams { vpcEndpointSubnetSelection?: cdk.aws_ec2.SubnetSelection; shouldCreateEcrVpcEndpoint?: boolean; shouldCreateSsmVpcEndpoint?: boolean; shouldCreateLogsVpcEndpoint?: boolean; shouldCreateS3VpcEndpoint?: boolean; } export interface EcsFargateParams { ecsFargateSubnetSelection: cdk.aws_ec2.SubnetSelection; clusterName: string; ecrRepositoryPrefix?: string; repositoryName: string; imagesTag: string; desiredCount: number; ecsServiceSecurityGroupIds?: string[]; inboundFromEcsServiceAllowedSecurityGroupId?: { securityGroupId: string; ports: cdk.aws_ec2.Port[]; }[]; } export interface EcsFargateBastionStackParams { env?: cdk.Environment; property: { vpcId: string; vpcEndpointParams?: VpcEndpointParams; ecsFargateParams: EcsFargateParams; }; } export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", // デプロイ先のVPCのID vpcEndpointParams: { // VPCエンドポイントを作成する時に使用するパラメーター vpcEndpointSubnetSelection: { // VPCエンドポイントをどのサブネットにデプロイするか指定 subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, shouldCreateEcrVpcEndpoint: true, // ECS関連のVPCエンドポイントを作成するか shouldCreateSsmVpcEndpoint: true, // SSM関連のVPCエンドポイントを作成するか shouldCreateLogsVpcEndpoint: true, // CloudWatch LogsのVPCエンドポイントを作成するか shouldCreateS3VpcEndpoint: true, // Gateway型のS3 VPCエンドポイントを作成するか }, ecsFargateParams: { // ECS Fargateを作成する時に使用するパラメーター ecsFargateSubnetSelection: { // ECS Fargateをどのサブネットにデプロイするか subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, clusterName: "ecs-fargate-bastion", // ECSクラスターの名前 ecrRepositoryPrefix: "ecr-public-pull-through", // Pull through cache ruleに指定するリポジトリのプレフィックス repositoryName: "ecr-public-pull-through/ubuntu/ubuntu", // リポジトリ名 imagesTag: "22.04", // 使用するコンテナイメージのタグ desiredCount: 1, // デプロイするタスクの数 ecsServiceSecurityGroupIds: [ // ECSサービスのSecurity GroupのID "sg-0e5bce3c653793012", "sg-0a15755f2fb642698", ], inboundFromEcsServiceAllowedSecurityGroupId: [ // ECSサービスからのインバウンド通信を許可するSecurity GroupのID { securityGroupId: "sg-0a15755f2fb642698", ports: [cdk.aws_ec2.Port.allTcp(), cdk.aws_ec2.Port.allIcmp()], }, ], }, }, };
やってみた
検証環境
検証環境は以下のとおりです。
こちらの環境は以下リポジトリのコードをベースに作成しました。
この環境上に用意したAWS CDKのコードを用いてECS Fargate Bastionをデプロイします。
Egress Subnetにデプロイ
まず、Egress Subnetにデプロイします。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", ecsFargateParams: { ecsFargateSubnetSelection: { subnetFilters: [ cdk.aws_ec2.SubnetFilter.byIds(["subnet-02ea7423910b506f4"]), ], }, clusterName: "ecs-fargate-bastion", repositoryName: "public.ecr.aws/ubuntu/ubuntu", imagesTag: "22.04", desiredCount: 1, ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"], }, }, };
構成図にすると以下のとおりです。
起動したECSのタスクを確認します。
試しにECS Execでコンテナに接続してみます。
$ cluster_name="ecs-fargate-bastion" $ task_id=$( aws ecs list-tasks \ --cluster "${cluster_name}" \ --query 'taskArns[0]' \ --output text \ | sed 's/.*'"${cluster_name}"'\///' ) $ aws ecs execute-command \ --cluster "${cluster_name}" \ --task "${task_id}" \ --container Container \ --interactive \ --command "/bin/sh" The Session Manager plugin was installed successfully. Use the AWS CLI to start a session. Starting session with SessionId: ecs-execute-command-03e53b704c35dfdb5 # ls -l total 56 lrwxrwxrwx 1 root root 7 Feb 27 16:01 bin -> usr/bin drwxr-xr-x 2 root root 4096 Apr 18 2022 boot drwxr-xr-x 5 root root 380 Mar 6 10:12 dev drwxr-xr-x 1 root root 4096 Mar 6 10:12 etc drwxr-xr-x 2 root root 4096 Apr 18 2022 home lrwxrwxrwx 1 root root 7 Feb 27 16:01 lib -> usr/lib drwxr-xr-x 3 root root 4096 Mar 6 10:12 managed-agents drwxr-xr-x 2 root root 4096 Feb 27 16:01 media drwxr-xr-x 2 root root 4096 Feb 27 16:01 mnt drwxr-xr-x 2 root root 4096 Feb 27 16:01 opt dr-xr-xr-x 188 root root 0 Mar 6 10:12 proc drwx------ 2 root root 4096 Feb 27 16:08 root drwxr-xr-x 5 root root 4096 Feb 27 16:08 run lrwxrwxrwx 1 root root 8 Feb 27 16:01 sbin -> usr/sbin drwxr-xr-x 2 root root 4096 Feb 27 16:01 srv dr-xr-xr-x 12 root root 0 Mar 6 10:12 sys drwxrwxrwt 2 root root 4096 Feb 27 16:08 tmp drwxr-xr-x 11 root root 4096 Feb 27 16:01 usr drwxr-xr-x 1 root root 4096 Feb 27 16:08 var # df -h Filesystem Size Used Avail Use% Mounted on overlay 30G 9.5G 19G 34% / tmpfs 64M 0 64M 0% /dev shm 461M 0 461M 0% /dev/shm tmpfs 461M 0 461M 0% /sys/fs/cgroup /dev/nvme0n1p1 4.9G 2.0G 2.9G 41% /dev/init /dev/nvme1n1 30G 9.5G 19G 34% /etc/hosts tmpfs 461M 0 461M 0% /proc/acpi tmpfs 461M 0 461M 0% /sys/firmware tmpfs 461M 0 461M 0% /proc/scsi # hostname ip-10-1-16-203.ec2.internal # hostname -a # exit Exiting session with sessionId: ecs-execute-command-03e53b704c35dfdb5.
問題なく操作できましたね。
ECS ExecのログはCloudwatch Logsに出力するようにしています。確認すると、確かにログが出力されていました。
ログを出力するにはECSクラスター側でログの出力設定しているのはもちろん、使用するコンテナイメージにはscript
とcat
がインストールされている必要があります。注意しましょう。
また、コマンドログを Amazon S3 または CloudWatch Logs に正しくアップロードするには、コンテナイメージに scriptと catをインストールする必要があることを知っておくことも重要です。
デバッグ用にAmazon ECS Exec を使用 - Amazon Elastic Container Service
続けて、SSMセッションマネージャーでも接続してみます。
$ runtime_id=$(aws ecs describe-tasks \ --cluster "${cluster_name}" \ --task "${task_id}" \ --query 'tasks[].containers[].runtimeId' \ --output text ) $ aws ssm start-session \ --target "ecs:${cluster_name}_${task_id}_${runtime_id}" Starting session with SessionId: botocore-session-1709724330-0d1f19c11d18fd7cc /bin/bash cd /home/$(whoami) # root@ip-10-1-16-203:/# cd /home/$(whoami) bash: cd: /home/root: No such file or directory root@ip-10-1-16-203:/# ps aufe USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 135 0.0 0.0 2304 828 pts/1 Ss 11:29 0:00 sh PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 ECS_CO root 136 0.0 0.3 4552 3584 pts/1 S 11:29 0:00 \_ /bin/bash HOSTNAME=ip-10-1-16-203.ec2.internal HOME=/root AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae AWS_EXECUTION_EN root 141 0.0 0.1 6828 1652 pts/1 R+ 11:30 0:00 \_ ps aufe AWS_EXECUTION_ENV=AWS_ECS_FARGATE AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae HOSTNAME=ip-10-1-16-203.ec2. root 1 0.0 0.0 828 4 pts/0 Ss 10:12 0:00 /dev/init -- /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0 root 6 0.0 0.3 4132 3144 pts/0 S+ 10:12 0:00 /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 root@ip-10-1-16-203:/# printenv AWS_EXECUTION_ENV=AWS_ECS_FARGATE AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae HOSTNAME=ip-10-1-16-203.ec2.internal AWS_DEFAULT_REGION=us-east-1 AWS_REGION=us-east-1 PWD=/ ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 HOME=/root LANG=C.UTF-8 LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36: ECS_AGENT_URI=http://169.254.170.2/api/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 TERM=xterm-256color ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 SHLVL=1 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin _=/usr/bin/printenv
問題なく操作できました。なお、SSMセッションマネージャーのセッションログをS3バケットやCloudWatch Logsに出力するようにしている場合は権限不足で接続できないと思われます。コードを変更して適切なアクセス許可をしてあげましょう。
SessionId: botocore-session-1709692953-074d742931e439454 : Couldn't start the session because we are unable to validate encryption on Amazon S3 bucket. Error: AccessDenied: Access Denied status code: 403, request id: M69254NJ2XKEX1KD, host id: aOYdS3DE1fH6rf8TOXvZpmSNmTBWx20+TAo+6n85VHoUAi2um0jDSwwWU9ESUxZzjwvexLbdVn0=
SSMセッションマネージャーのリモートホストのポートフォワーディングも試してみましょう。SSMセッションマネージャーのリモートホストのポートフォワーディングの紹介は以下記事をご覧ください。
Aurora PostgreSQLへのポートフォワーディングをしてあげます。
$ db_endopoint="db-cluster.cluster-cicjym7lykmq.us-east-1.rds.amazonaws.com" $ aws ssm start-session \ --target "ecs:${cluster_name}_${task_id}_${runtime_id}" \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ --parameters '{"host":["'${db_endopoint}'"],"portNumber":["5432"], "localPortNumber":["15432"]}' Starting session with SessionId: botocore-session-1709724330-0eb982afb8a0e4f31 Port 15432 opened for sessionId botocore-session-1709724330-0eb982afb8a0e4f31. Waiting for connections...
別セッションでポートフォワーディングしているポートに対して接続します。
# Autora PostgreSQLの認証情報取得 $ get_secrets_value=$(aws secretsmanager get-secret-value \ --secret-id AuroraSecret7ACECA7F-jZsiEVe2jrDs \ --region us-east-1 \ | jq -r .SecretString) $ export PGHOST=localhost $ export PGPORT=15432 $ export PGDATABASE=$(echo "${get_secrets_value}" | jq -r .dbname) $ export PGUSER=$(echo "${get_secrets_value}" | jq -r .username) $ export PGPASSWORD=$(echo "${get_secrets_value}" | jq -r .password) $ psql psql (16.2, server 15.5) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) Type "help" for help. testDB=> SELECT version(); version ------------------------------------------------------------------------------------------------------------- PostgreSQL 15.5 on aarch64-unknown-linux-gnu, compiled by aarch64-unknown-linux-gnu-gcc (GCC) 9.5.0, 64-bit (1 row) testDB=> SELECT aurora_db_instance_identifier(); aurora_db_instance_identifier ------------------------------- db-instance-writer (1 row) testDB=> SELECT * FROM aurora_global_db_instance_status(); server_id | session_id | aws_region | durable_lsn | highest_lsn_rcvd | feedback_epoch | feedback_xmin | oldest_read_view_lsn | visibility_lag_in_msec --------------------+-------------------+------------+-------------+------------------+----------------+---------------+----------------------+------------------------ db-instance-writer | MASTER_SESSION_ID | us-east-1 | 179417812 | | | | | (1 row) testDB=> SELECT inet_client_addr(); inet_client_addr ------------------ 10.1.16.211 (1 row)
問題なく接続でき、クエリを叩くこともできました。接続元のIPアドレスはECSタスクのIPアドレスになっています。
Egress Subnetにデプロイ × Pull through cache(初回)
続いて、Pull through cacheを試してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", ecsFargateParams: { ecsFargateSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS, availabilityZones: ["us-east-1a"], }, clusterName: "ecs-fargate-bastion", ecrRepositoryPrefix: "ecr-public-pull-through", repositoryName: "ecr-public-pull-through/ubuntu/ubuntu", imagesTag: "22.04", desiredCount: 1, ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"], }, }, };
デプロイ後、ECRのプライベートリポジトリを確認すると、指定したコンテナイメージがpushされていました。
起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
Isolated Subnetにデプロイ × Pull through cache(2回目)
次に、Isolated Subnetにデプロイします。
続いて、Pull through cacheを試してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", vpcEndpointParams: { vpcEndpointSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, shouldCreateEcrVpcEndpoint: true, shouldCreateSsmVpcEndpoint: true, shouldCreateLogsVpcEndpoint: true, shouldCreateS3VpcEndpoint: true, }, ecsFargateParams: { ecsFargateSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, clusterName: "ecs-fargate-bastion", ecrRepositoryPrefix: "ecr-public-pull-through", repositoryName: "ecr-public-pull-through/ubuntu/ubuntu", imagesTag: "22.04", desiredCount: 1, ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"], }, }, };
構成図にすると以下のとおりです。
デプロイ後、起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。
Isolated Subnetなのでamazon-ecs-exec-checkerでも叩いてみます。
$ ./check-ecs-exec.sh ecs-fargate-bastion be519c666386432289d355afb155162b ------------------------------------------------------------- Prerequisites for check-ecs-exec.sh v0.7 ------------------------------------------------------------- jq | OK (/opt/homebrew/bin/jq) AWS CLI | OK (/opt/homebrew/bin/aws) ------------------------------------------------------------- Prerequisites for the AWS CLI to use ECS Exec ------------------------------------------------------------- AWS CLI Version | OK (aws-cli/2.15.15 Python/3.11.7 Darwin/23.2.0 source/arm64 prompt/off) Session Manager Plugin | OK (1.2.553.0) ------------------------------------------------------------- Checks on ECS task and other resources ------------------------------------------------------------- Region : us-east-1 Cluster: ecs-fargate-bastion Task : be519c666386432289d355afb155162b ------------------------------------------------------------- Cluster Configuration | KMS Key : Not Configured Audit Logging : OVERRIDE S3 Bucket Name: Not Configured CW Log Group : /ecs/ecs-fargate-bastion/ecr-public-pull-through/ubuntu/ubuntu/ecs-exec, Encryption Enabled: false Can I ExecuteCommand? | arn:aws:iam::<AWSアカウントID>:role/<IAMロール名> ecs:ExecuteCommand: allowed ssm:StartSession denied?: allowed Task Status | RUNNING Launch Type | Fargate Platform Version | 1.4.0 Exec Enabled for Task | OK Container-Level Checks | ---------- Managed Agent Status ---------- 1. RUNNING for "Container" ---------- Init Process Enabled (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11) ---------- 1. Enabled - "Container" ---------- Read-Only Root Filesystem (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11) ---------- 1. Disabled - "Container" Task Role Permissions | arn:aws:iam::<AWSアカウントID>:role/EcsFargateBastionStack-EcsFargateTaskDefinitionTask-CirFF1nrXFna ssmmessages:CreateControlChannel: allowed ssmmessages:CreateDataChannel: allowed ssmmessages:OpenControlChannel: allowed ssmmessages:OpenDataChannel: allowed ----- logs:DescribeLogGroups: allowed logs:CreateLogStream: allowed logs:DescribeLogStreams: allowed logs:PutLogEvents: allowed VPC Endpoints | Found existing endpoints for vpc-0c923cc42e5fb2cbf: - com.amazonaws.us-east-1.s3 - com.amazonaws.us-east-1.logs - com.amazonaws.us-east-1.ecr.api - com.amazonaws.us-east-1.ecr.dkr - com.amazonaws.us-east-1.ssmmessages - com.amazonaws.us-east-1.ssm Environment Variables | (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11) 1. container "Container" - AWS_ACCESS_KEY: not defined - AWS_ACCESS_KEY_ID: not defined - AWS_SECRET_ACCESS_KEY: not defined
特に問題なさそうです。必要なVPCエンドポイントを正しく認識しています。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
Isolated Subnetにデプロイ × Pull through cache(初回)
次に、初めてPull through cacheを使用してイメージをpullする際にインターネットへの接続がない場合の挙動を確認してみます。
AWS公式ドキュメントには以下のように「初めてpullする場合はNAT Gatewayが必要」との記載があります。
初めてプルスルーキャッシュルールを使用してイメージをプルするとき、AWS PrivateLink を使って、インターフェイス VPC エンドポイントを使用するように Amazon ECR を設定した場合、NAT ゲートウェイを使用して、同じ VPC 内にパブリックサブネットを作成し、プルが機能するように、プライベートサブネットから NAT ゲートウェイへのすべてのアウトバウンドトラフィックをインターネットにルーティングする必要があります。その後のイメージプルでは、これは必要ありません。詳細については、Amazon Virtual Private Cloud ユーザーガイドの「シナリオ: プライベートサブネットからインターネットにアクセスする」を参照してください。
Amazon ECR インターフェイス VPC エンドポイント (AWS PrivateLink) - Amazon ECR
しかし、以下記事ではNAT Gatewayへのルートが存在しない場合でも初回のpullができたと紹介されています。
実際に私も試してみました。
Pull through cache ruleで使用するリポジトリプレフィックスを変更してみます。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", vpcEndpointParams: { vpcEndpointSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, shouldCreateEcrVpcEndpoint: true, shouldCreateSsmVpcEndpoint: true, shouldCreateLogsVpcEndpoint: true, shouldCreateS3VpcEndpoint: true, }, ecsFargateParams: { ecsFargateSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, clusterName: "ecs-fargate-bastion", ecrRepositoryPrefix: "ecr-public-pull-through2", repositoryName: "ecr-public-pull-through2/ubuntu/ubuntu", imagesTag: "22.04", desiredCount: 1, ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"], }, }, };
デプロイ後、起動してきたECSタスクを確認すると、変更後のPull through cache ruleで指定したリポジトリプレフィックスのコンテナイメージを使っていることがわかりました。
先述の記事で紹介しているとおり、NAT Gatewayへのルートが存在しない場合でも初回のpullができるのでしょうか。
今度はコンテナイメージを変更してみます。Ubuntu 22.04からbusyboxに変更します。./lib/parameter/index.ts
は以下のとおりです。
export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = { env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION, }, property: { vpcId: "vpc-0c923cc42e5fb2cbf", vpcEndpointParams: { vpcEndpointSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, shouldCreateEcrVpcEndpoint: true, shouldCreateSsmVpcEndpoint: true, shouldCreateLogsVpcEndpoint: true, shouldCreateS3VpcEndpoint: true, }, ecsFargateParams: { ecsFargateSubnetSelection: { subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED, availabilityZones: ["us-east-1a"], }, clusterName: "ecs-fargate-bastion", ecrRepositoryPrefix: "ecr-public-pull-through3", repositoryName: "ecr-public-pull-through3/docker/library/busybox", imagesTag: "stable-musl", imagesTag: "22.04", desiredCount: 1, ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"], }, }, };
デプロイ後に起動してきたECSタスクを確認すると、確かにbusyboxのイメージを使用しています。
Pull through cacheで作成されたリポジトリにもイメージがpushされています。
この後もスタックを一から作り直して再チャレンジしましたが、結果は同じでした。NAT Gatewayへのルートが存在しない場合でも初回のpullができるようです。
ちなみに、コンテナイメージをUbuntu 22.04からbusyboxに変更すると、スタックを更新してからタスクの起動が完了するまでの時間が4分から1分と3分短くなりました。
また、タスクを停止させて、新しいタスクが実行中になるまでにかかった時間は30秒ほどでした(停止をしてから新しいタスクを起動し始めるまで15秒 /起動したタスクが実行中になるまで15秒)。このぐらいの速度であれば通常はタスクの数を0にしておいて、必要になったタイミングで1に変更するといった運用も耐えられそうです。
起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。
NAT Gatewayがないサブネットにも簡単に踏み台を用意できる
AWS CDKでECS Fargate Bastionを一撃で作ってみました。
NAT Gatewayがないサブネットにも簡単に踏み台を用意できましたね。
なお、コンテナにECS ExecやSSMセッションマネージャーして、そこからコマンドを色々叩きたい方はコンテナイメージのビルドが必要です。ぜひカスタマイズしてみてください。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!